'use strict';

/**
 * @typedef {Object<string, ComponentCategory>} Components
 * @typedef {Object<string, ComponentEntry | string>} ComponentCategory
 *
 * @typedef ComponentEntry
 * @property {string} [title] The title of the component.
 * @property {string} [owner] The GitHub user name of the owner.
 * @property {boolean} [noCSS=false] Whether the component doesn't have style sheets which should also be loaded.
 * @property {string | string[]} [alias] An optional list of aliases for the id of the component.
 * @property {Object<string, string>} [aliasTitles] An optional map from an alias to its title.
 *
 * Aliases which are not in this map will the get title of the component.
 * @property {string | string[]} [optional]
 * @property {string | string[]} [require]
 * @property {string | string[]} [modify]
 */

var getLoader = (function () {

  /**
   * A function which does absolutely nothing.
   *
   * @type {any}
   */
  var noop = function () {
  };

  /**
   * Invokes the given callback for all elements of the given value.
   *
   * If the given value is an array, the callback will be invokes for all elements. If the given value is `null` or
   * `undefined`, the callback will not be invoked. In all other cases, the callback will be invoked with the given
   * value as parameter.
   *
   * @param {null | undefined | T | T[]} value
   * @param {(value: T, index: number) => void} callbackFn
   * @returns {void}
   * @template T
   */
  function forEach(value, callbackFn) {
    if (Array.isArray(value)) {
      value.forEach(callbackFn);
    } else if (value != null) {
      callbackFn(value, 0);
    }
  }

  /**
   * Returns a new set for the given string array.
   *
   * @param {string[]} array
   * @returns {StringSet}
   *
   * @typedef {Object<string, true>} StringSet
   */
  function toSet(array) {
    /** @type {StringSet} */
    var set = {};
    for (var i = 0, l = array.length; i < l; i++) {
      set[array[i]] = true;
    }
    return set;
  }

  function createEntryMap(components) {
    /** @type {Object<string, Readonly<ComponentEntry>>} */
    var map = {};

    for (var categoryName in components) {
      var category = components[categoryName];
      for (var id in category) {
        if (id != 'meta') {
          /** @type {ComponentEntry | string} */
          var entry = category[id];
          map[id] = typeof entry == 'string' ? { title: entry } : entry;
        }
      }
    }

    return map;
  }

  /**
   * Creates a full dependencies map which includes all types of dependencies and their transitive dependencies.
   *
   * @param {EntryMap} entryMap
   * @returns {DependencyResolver}
   *
   * @typedef {(id: string) => StringSet} DependencyResolver
   */
  function createDependencyResolver(entryMap) {
    /** @type {Object<string, StringSet>} */
    var map = {};
    var _stackArray = [];

    /**
     * Adds the dependencies of the given component to the dependency map.
     *
     * @param {string} id
     * @param {string[]} stack
     */
    function addToMap(id, stack) {
      if (id in map) {
        return;
      }

      stack.push(id);

      // check for circular dependencies
      var firstIndex = stack.indexOf(id);
      if (firstIndex < stack.length - 1) {
        throw new Error('Circular dependency: ' + stack.slice(firstIndex).join(' -> '));
      }

      /** @type {StringSet} */
      var dependencies = {};

      var entry = entryMap[id];
      if (entry) {
        /**
         * This will add the direct dependency and all of its transitive dependencies to the set of
         * dependencies of `entry`.
         *
         * @param {string} depId
         * @returns {void}
         */
        function handleDirectDependency(depId) {
          if (!(depId in entryMap)) {
            throw new Error(id + ' depends on an unknown component ' + depId);
          }
          if (depId in dependencies) {
            // if the given dependency is already in the set of deps, then so are its transitive deps
            return;
          }

          addToMap(depId, stack);
          dependencies[depId] = true;
          for (var transitiveDepId in map[depId]) {
            dependencies[transitiveDepId] = true;
          }
        }

        forEach(entry.require, handleDirectDependency);
        forEach(entry.optional, handleDirectDependency);
        forEach(entry.modify, handleDirectDependency);
      }

      map[id] = dependencies;

      stack.pop();
    }

    return function (id) {
      var deps = map[id];
      if (!deps) {
        addToMap(id, _stackArray);
        deps = map[id];
      }
      return deps;
    };
  }

  /**
   * Returns a function which resolves the aliases of its given id of alias.
   *
   * @param {EntryMap} entryMap
   * @returns {(idOrAlias: string) => string}
   */
  function createAliasResolver(entryMap) {
    /** @type {Object<string, string> | undefined} */
    var map;

    return function (idOrAlias) {
      if (idOrAlias in entryMap) {
        return idOrAlias;
      } else {
        // only create the alias map if necessary
        if (!map) {
          map = {};

          for (var id in entryMap) {
            var entry = entryMap[id];
            forEach(entry && entry.alias, function (alias) {
              if (alias in map) {
                throw new Error(alias + ' cannot be alias for both ' + id + ' and ' + map[alias]);
              }
              if (alias in entryMap) {
                throw new Error(alias + ' cannot be alias of ' + id + ' because it is a component.');
              }
              map[alias] = id;
            });
          }
        }
        return map[idOrAlias] || idOrAlias;
      }
    };
  }

  /**
   * @typedef LoadChainer
   * @property {(before: T, after: () => T) => T} series
   * @property {(values: T[]) => T} parallel
   * @template T
   */

  /**
   * Creates an implicit DAG from the given components and dependencies and call the given `loadComponent` for each
   * component in topological order.
   *
   * @param {DependencyResolver} dependencyResolver
   * @param {StringSet} ids
   * @param {(id: string) => T} loadComponent
   * @param {LoadChainer<T>} [chainer]
   * @returns {T}
   * @template T
   */
  function loadComponentsInOrder(dependencyResolver, ids, loadComponent, chainer) {
    var series = chainer ? chainer.series : undefined;
    var parallel = chainer ? chainer.parallel : noop;

    /** @type {Object<string, T>} */
    var cache = {};

    /**
     * A set of ids of nodes which are not depended upon by any other node in the graph.
     *
     * @type {StringSet}
     */
    var ends = {};

    /**
     * Loads the given component and its dependencies or returns the cached value.
     *
     * @param {string} id
     * @returns {T}
     */
    function handleId(id) {
      if (id in cache) {
        return cache[id];
      }

      // assume that it's an end
      // if it isn't, it will be removed later
      ends[id] = true;

      // all dependencies of the component in the given ids
      var dependsOn = [];
      for (var depId in dependencyResolver(id)) {
        if (depId in ids) {
          dependsOn.push(depId);
        }
      }

      /**
       * The value to be returned.
       *
       * @type {T}
       */
      var value;

      if (dependsOn.length === 0) {
        value = loadComponent(id);
      } else {
        var depsValue = parallel(dependsOn.map(function (depId) {
          var value = handleId(depId);
          // none of the dependencies can be ends
          delete ends[depId];
          return value;
        }));
        if (series) {
          // the chainer will be responsibly for calling the function calling loadComponent
          value = series(depsValue, function () {
            return loadComponent(id);
          });
        } else {
          // we don't have a chainer, so we call loadComponent ourselves
          loadComponent(id);
        }
      }

      // cache and return
      return cache[id] = value;
    }

    for (var id in ids) {
      handleId(id);
    }

    /** @type {T[]} */
    var endValues = [];
    for (var endId in ends) {
      endValues.push(cache[endId]);
    }
    return parallel(endValues);
  }

  /**
   * Returns whether the given object has any keys.
   *
   * @param {object} obj
   */
  function hasKeys(obj) {
    for (var key in obj) {
      return true;
    }
    return false;
  }

  /**
   * Returns an object which provides methods to get the ids of the components which have to be loaded (`getIds`) and
   * a way to efficiently load them in synchronously and asynchronous contexts (`load`).
   *
   * The set of ids to be loaded is a superset of `load`. If some of these ids are in `loaded`, the corresponding
   * components will have to reloaded.
   *
   * The ids in `load` and `loaded` may be in any order and can contain duplicates.
   *
   * @param {Components} components
   * @param {string[]} load
   * @param {string[]} [loaded=[]] A list of already loaded components.
   *
   * If a component is in this list, then all of its requirements will also be assumed to be in the list.
   * @returns {Loader}
   *
   * @typedef Loader
   * @property {() => string[]} getIds A function to get all ids of the components to load.
   *
   * The returned ids will be duplicate-free, alias-free and in load order.
   * @property {LoadFunction} load A functional interface to load components.
   *
   * @typedef {<T> (loadComponent: (id: string) => T, chainer?: LoadChainer<T>) => T} LoadFunction
   * A functional interface to load components.
   *
   * The `loadComponent` function will be called for every component in the order in which they have to be loaded.
   *
   * The `chainer` is useful for asynchronous loading and its `series` and `parallel` functions can be thought of as
   * `Promise#then` and `Promise.all`.
   *
   * @example
   * load(id => { loadComponent(id); }); // returns undefined
   *
   * await load(
   *     id => loadComponentAsync(id), // returns a Promise for each id
   *     {
   *         series: async (before, after) => {
   *             await before;
   *             await after();
   *         },
   *         parallel: async (values) => {
   *             await Promise.all(values);
   *         }
   *     }
   * );
   */
  function getLoader(components, load, loaded) {
    var entryMap = createEntryMap(components);
    var resolveAlias = createAliasResolver(entryMap);

    load = load.map(resolveAlias);
    loaded = (loaded || []).map(resolveAlias);

    var loadSet = toSet(load);
    var loadedSet = toSet(loaded);

    // add requirements

    load.forEach(addRequirements);

    function addRequirements(id) {
      var entry = entryMap[id];
      forEach(entry && entry.require, function (reqId) {
        if (!(reqId in loadedSet)) {
          loadSet[reqId] = true;
          addRequirements(reqId);
        }
      });
    }

    // add components to reload

    // A component x in `loaded` has to be reloaded if
    //  1) a component in `load` modifies x.
    //  2) x depends on a component in `load`.
    // The above two condition have to be applied until nothing changes anymore.

    var dependencyResolver = createDependencyResolver(entryMap);

    /** @type {StringSet} */
    var loadAdditions = loadSet;
    /** @type {StringSet} */
    var newIds;
    while (hasKeys(loadAdditions)) {
      newIds = {};

      // condition 1)
      for (var loadId in loadAdditions) {
        var entry = entryMap[loadId];
        forEach(entry && entry.modify, function (modId) {
          if (modId in loadedSet) {
            newIds[modId] = true;
          }
        });
      }

      // condition 2)
      for (var loadedId in loadedSet) {
        if (!(loadedId in loadSet)) {
          for (var depId in dependencyResolver(loadedId)) {
            if (depId in loadSet) {
              newIds[loadedId] = true;
              break;
            }
          }
        }
      }

      loadAdditions = newIds;
      for (var newId in loadAdditions) {
        loadSet[newId] = true;
      }
    }

    /** @type {Loader} */
    var loader = {
      getIds: function () {
        var ids = [];
        loader.load(function (id) {
          ids.push(id);
        });
        return ids;
      }, load: function (loadComponent, chainer) {
        return loadComponentsInOrder(dependencyResolver, loadSet, loadComponent, chainer);
      }
    };

    return loader;
  }

  return getLoader;

}());

if (typeof module !== 'undefined') {
  module.exports = getLoader;
}
